Why `typeof` is no longer "safe"


#1

I was reading "Understanding ES6" by Nicholas Zakas and something caught my attention.

In a section on let/const, there was this snippet:

if (condition) {
    console.log(typeof value);     // ReferenceError!
    let value = "blue";
}

I almost spilled my coffee.

Is typeof operator no longer the absolutely and the only safe way to check for anything, whether it's undeclared values or something as crazy as IE's host objects with their "unknown" type and which blow up on [[Get]]?

I ran it in Chrome and FF just to confirm — both errored out.

Opened spec, looking at typeof operator — behavior is more or less the same as in ES5. So what's the trick? How is it possible?

After some investigation, here's what I found.

First of all, 13.2.1 has an (informal?) note:

let and const declarations define variables that are scoped to the running execution context’s LexicalEnvironment. The variables are created when their containing Lexical Environment is instantiated but may not be accessed in any way until the variable’s LexicalBinding is evaluated. A variable defined by a LexicalBinding with an Initializer is assigned the value of its Initializer’s AssignmentExpression when the LexicalBinding is evaluated, not when the variable is created. If a LexicalBinding in a let declaration does not have an Initializer the variable is assigned the value undefined when the LexicalBinding is evaluated.

Notice "may not be accessed" (emphasis mine). It sort of hints at this behavior but this is informal and doesn't explain much.

Ok, let's take a look at what exactly is happening:

if (1) { typeof x; let x; }

1) When the block is evaluated

13.1.11 — BlockDeclarationInstantiation('typeof x; let x;')

For each element d in declarations do
...
Let status be the result of calling env’s CreateMutableBinding concrete method passing dn and false as the arguments.

8.1.1.1.2 - CreateMutableBinding(x)

Create a mutable binding in envRec for N and record that it is uninitialized.

let x creates a mutable lexical x binding.

2) Execution of typeof x

12.5.6 — typeof x

6.2.3.1 — GetValue(x)

8.1.1 — GetBindingValue(x)

If the binding exists but is uninitialized a ReferenceError is thrown, regardless of the value of S.

(vs. in ES5)

return the value currently bound to N in envRec.

So (declared but) uninitialized bindings now throw ReferenceError's in ES6. But what happens with var's? Why don't they throw in similar cases? Doesn't var also declare uninitialized binding?

if (1) { typeof x; var x; }

Turns out the answer is in 13.2.2 — VariableStatement:

Var variables are created when their containing Lexical Environment is instantiated and are initialized to undefined when created.

So the reason typeof x doesn't throw for var x but throws for let x is because var not only creates but also initializes binding to undefined, whereas let merely declares it.

Curiously, in ES5 var declarations create mutable bindings with undefined values too. But the difference seems to be that in ES5 you couldn't create uninitialized mutable bindings (both variable and function declarations would always set values). In ES5, only CreateImmutableBinding operation could create uninitialized binding, and as far as I can see, there was no way to perform that in user code. CreateImmutableBinding was only used in 2 places — to create arguments binding (in strict code) and to make NFE's function identifier available ("inject") to the scope of the function itself.

So there's a new typeof — a long-standing rule that is no longer true in ES6 smile


#2

In which Firefox does this error out for you? In FF 34 it logs "undefined" for me.


#3

Yeah, I couldn't believe this either. I need to update the book with the term "temporal dead zone", which is used on esdiscuss to talk about the state of a block binding before the declaration is encountered.

Anything in the temporal dead zone throws an error when an attempt is made to access it (for all the reasons you mentioned). The sad side effect being that typeof is no longer a 100% safe operation. frowning


#4

Running in FF37 console:

(function(){ console.log(x); let x; })()

I get "ReferenceError: can't access lexical declaration `x' before initialization"

I see that console.log(x); let x; on its own logs undefined. Not sure why, but I'm guessing because a) global let semantics is different or b) console uses some form of eval and eval let semantics are different.

Need to look more into it.


#5

I recall seeing "temporal dead zone" here and there — thanks for clarifying! Makes perfect sense.


#6

I can confirm that Chrome (including Canary) errors out on for:

(function(){ console.log(x); let x; })()

Chrome Version 39.0.2171.95 (64-bit)

SyntaxError: Unexpected identifier

Version 41.0.2269.0 canary (64-bit)

SyntaxError: Block-scoped declarations (let, const, function, class) not yet supported outside strict mode

Both with experimental flags enabled.


#7

So this implies that nowadays es6 code which is working well with 6to5 transpiler may not work in the future, throwing ReferenceError...


#8

Well, how does the transpiler handle let? Since ES5 does not have block-level scoping, there have to be some compromises made. This should not happen in the case your let statement is at the top of the block. So I'm not sure how big an issue it will be in practice. (It's still a little worrying, regardless.)


#9

Most ES6 transpilers are using "hygenic variable renaming", and in rarer cases the IIFE approach, for converting let. A few older ones are using the try..catch hack.

Examples:

{ let x = 2; console.log( x ); }

That code will often just become:

{ var x = 2; console.log( x ); }

Because you aren't trying to use an x outside the block. If you do:

var x = 1; { let x = 2; console.log( x ); }

Then it typically is transpiled to something like:

var x = 1; { var __x$0001 = 2; console.log( __x$0001 ); }

That's the "hygenic renaming" technique and seems to be preferred. let inside loops if a closure is present will use an IIFE to preserve the closure over the var. The old school hack for block scoping was:

try {throw 2;} catch(x) { console.log( x ); }

In all these cases, the block scoping part works, but the TDZ part (and its errors) do not.

I've complained before several times in unofficial channels (aka twitter) about this discrepancy and mostly been told it was not an important issue. frowning


#10

Seeing undefined in Firefox 34.


#11

imo when using a transpiler, you should never assume your code will work in the future - because you're relying on avoiding both engine bugs and transpiler bugs simultaneously. You'll always have to keep up to date with transpiler changes, and even when your code is the same, you should recompile and redeploy whenever the transpiler updates.


#12

Just an FYI but as of 6to5 3.5.0 there's an optional es6.blockScopingTDZ transformer that fully implements the runtime TDZ suggested in this Traceur issue.


#13

Any idea when the 6to5 REPL will be updated? I wanted to try it out online but it doesn't seem to include one.


#14

You can't enable a lot of the options currently from the REPL (tracking that here). I can push out a quick patch that adds toggles for some of the optional transformers if you want.

It's currently an optional transformer because it kills perf and would be a breaking change. I'm considering making it enabled by default for the next major (as well as some of the other optional spec transformers such as typeofSymbol) and making people disable it if they care because spec compliancy is more important than performance by default IMO.

Here's an example of some of the input/output:

function foo() {
  bar;
}

foo();
let bar = "foobar";

var _temporalAssertDefined = function (val, name, undef) { if (val === undef) { throw new ReferenceError(name + " is not defined - temporal dead zone"); } return true; };

var _temporalUndefined = {};

var bar = _temporalUndefined;
function foo() {
  _temporalAssertDefined(bar, "bar", _temporalUndefined) && bar;
}

foo();

bar = "foobar";

#15

I've added a spec option to the REPL. Let me know if you run into any issues, working on cleaning up the output formatting.


#16

Great stuff, and impressive work. I do think it would be better to have this on by default and have "loose" mode turn it off, to avoid the upgrade hazards mentioned in this thread. But overall very happy that we now have a way to demonstrate TDZ in transpilers.

(TCO is even more impressive BTW, since unlike TDZ we can't demonstrate that in any current or canary browsers.)


#17

Thanks! Yeah, I'm most likely going to enable it by default in the next major which will be pretty soon since 6to5 is going to be renamed. I've been working pretty solidly on handling edge cases and spec compliancy since I feel confident digging into the spec now.